SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
10.7 KB · 198 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import { notFound, redirect } from 'next/navigation';3import { t } from '@/i18n';4import { api, isNotBuilt, safe } from '@/lib/api';5import { apiCompare } from '@/lib/api-compare';6import { MIN_COMPARE_COUNTRIES, compareCanonicalQuery, parseCompareState, splitCompareSlugs, type CompareState } from '@/lib/compare-state';7import { routes } from '@/lib/site';8import { topicById } from '@/lib/topics';9import type { CountrySummary, IndicatorCard } from '@/lib/types';10import { apiModeOf, toCountryLite, type CompareSeries, type CompareSnapshotRow, type CountryLite } from '@/lib/types-compare';11import { seoTitle } from '@/lib/seo';12import { HeadToHead } from '@/components/compare/head-to-head';13import { CompareChart } from '@/components/compare/compare-chart';14import { ChartGrid, ChartSections } from '@/components/compare/chart-sections';15import { CompareControls } from '@/components/compare/compare-controls';16import { CustomPanel } from '@/components/compare/custom-panel';17import { SnapshotTable } from '@/components/compare/snapshot-table';18import { NotBuiltState } from '@/components/data/empty-state';19import { Section } from '@/components/data/section';2021export const revalidate = 900;2223type Params = { slugs: string[] };24type SP = Record<string, string | string[] | undefined>;25const EAGER = 4; // charts rendered with server-fetched series; the rest fetch on scroll2627/** Resolve path segments (slugs or ISO3) against the country list, preserving order; null when the API is not built. */28async function resolve(segments: string[]): Promise<{ countries: CountrySummary[]; all: CountrySummary[] } | 'not-built'> {29  let list;30  try {31    list = await api.countries();32  } catch (e) {33    if (isNotBuilt(e)) return 'not-built';34    throw e;35  }36  const bySlug = new Map<string, CountrySummary>();37  for (const c of list.items) {38    if (c.slug) bySlug.set(c.slug.toLowerCase(), c);39    bySlug.set(c.id.toLowerCase(), c);40  }41  const seen = new Set<string>();42  const countries: CountrySummary[] = [];43  for (const s of splitCompareSlugs(segments)) {44    const c = bySlug.get(s);45    if (c && !seen.has(c.id)) {46      seen.add(c.id);47      countries.push(c);48    }49  }50  return { countries, all: list.items };51}5253function namesOf(cs: CountrySummary[]): string {54  return cs.map((c) => c.name ?? c.id).join(` ${t('compare.vs')} `);55}5657export async function generateMetadata({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }): Promise<Metadata> {58  const [{ slugs }, sp] = await Promise.all([params, searchParams]);59  const r = await resolve(slugs);60  if (r === 'not-built' || r.countries.length < MIN_COMPARE_COUNTRIES) return { title: t('compare.notFound'), robots: { index: false } };61  const state = parseCompareState(sp);62  const names = namesOf(r.countries);63  const tabName = state.tab === 'snapshot' ? null : state.tab === 'custom' ? t('compare.tab.custom') : topicById(state.tab)?.name ?? state.tab;64  const title = tabName ? `${seoTitle.compare(r.countries.map((c) => c.name ?? c.id))} — ${tabName}` : seoTitle.compare(r.countries.map((c) => c.name ?? c.id));65  const description = t('compare.pageDescription', { names, list: 'GDP, GDP per capita, growth, inflation, unemployment, life expectancy' });66  const canonical = `${routes.compare(...r.countries.map((c) => c.slug ?? c.id))}${compareCanonicalQuery(state)}`;67  return {68    title,69    description,70    alternates: { canonical },71    robots: state.tab === 'custom' ? { index: false, follow: true } : undefined,72    openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article', images: [{ url: routes.compareOg(r.countries.map((c) => c.slug ?? c.id)), width: 1200, height: 630, alt: names }] },73    twitter: { card: 'summary_large_image', title, description, images: [routes.compareOg(r.countries.map((c) => c.slug ?? c.id))] },74  };75}7677export default async function CompareViewPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) {78  const [{ slugs: segments }, sp] = await Promise.all([params, searchParams]);79  const r = await resolve(segments);80  if (r === 'not-built') return <NotBuiltState />;81  // A single country (e.g. the "Compare" action of a country page) opens the builder with it pre-selected.82  if (r.countries.length === 1) redirect(`${routes.compare()}?c=${r.countries[0]!.slug ?? r.countries[0]!.id.toLowerCase()}`);83  if (r.countries.length < MIN_COMPARE_COUNTRIES) notFound();8485  let state: CompareState = parseCompareState(sp);86  // `?indicator=` without a tab → custom tab with that single indicator (the snapshot table always sets a tab).87  if (state.indicator && state.tab === 'snapshot') state = { ...state, tab: 'custom', indicators: Array.from(new Set([state.indicator, ...state.indicators])).slice(0, 6) };8889  const countries: CountryLite[] = r.countries.map(toCountryLite);90  const all: CountryLite[] = r.all.filter((c) => c.kind !== 'aggregate').map(toCountryLite).sort((a, b) => a.name.localeCompare(b.name));91  const ids = countries.map((c) => c.id);92  const slugs = countries.map((c) => c.slug);93  const names = namesOf(r.countries);9495  // Data for the active tab96  const topic = state.tab !== 'snapshot' && state.tab !== 'custom' ? state.tab : null;97  const customSlugs = state.tab === 'custom' ? state.indicators : [];98  const snapshotP = state.tab === 'custom' ? (customSlugs.length ? safe(apiCompare.snapshot(ids, { indicators: customSlugs })) : Promise.resolve(null)) : safe(apiCompare.snapshot(ids, { topic }));99  const heroP = state.indicator && state.tab !== 'snapshot' ? safe(apiCompare.compare(ids, [state.indicator], { from: state.from, to: state.to, mode: apiModeOf(state.mode) })) : Promise.resolve(null);100  const [snapshot, hero] = await Promise.all([snapshotP, heroP]);101102  const rows: CompareSnapshotRow[] = (snapshot?.rows ?? []).filter((row) => ids.some((id) => row.values[id]?.has_data));103  const maxYear = Math.max(new Date().getUTCFullYear(), ...rows.flatMap((row) => Object.values(row.values).map((v) => v.year ?? 0)));104105  // Eager series for the first charts of a chart tab (one request, ≤ 8 indicators).106  let eager: Map<string, CompareSeries[]> = new Map();107  if (topic || state.tab === 'custom') {108    const eagerSlugs = rows109      .map((row) => row.indicator.slug)110      .filter((s) => s !== state.indicator)111      .slice(0, state.tab === 'custom' ? 6 : EAGER);112    if (eagerSlugs.length) {113      const bundle = await safe(apiCompare.compare(ids, eagerSlugs, { from: state.from, to: state.to, mode: apiModeOf(state.mode) }));114      if (bundle) {115        eager = new Map(eagerSlugs.map((s) => [s, bundle.series.filter((x) => x.indicator.slug === s)]));116      }117    }118  }119120  const heroCard: IndicatorCard | null = hero?.indicators[0] ?? rows.find((row) => row.indicator.slug === state.indicator)?.indicator ?? null;121  const heroRow = rows.find((row) => row.indicator.slug === state.indicator) ?? null;122  const gridRows = rows.filter((row) => row.indicator.slug !== state.indicator);123  const downloadIndicators = rows.map((row) => row.indicator.slug).slice(0, 40);124  const downloadHref = downloadIndicators.length ? routes.compareDownload(ids, downloadIndicators, { from: state.from, to: state.to }) : null;125  const topicDef = topic ? topicById(topic) : null;126  const tabLabel = state.tab === 'snapshot' ? t('compare.tab.snapshot') : state.tab === 'custom' ? t('compare.tab.custom') : (topicDef?.name ?? state.tab);127128  return (129    <>130      <header className="pb-3 pt-5 md:pt-8">131        <div className="eyebrow">{t('compare.title')}</div>132        <h1 className="display mt-1 text-2xl leading-tight text-ink md:text-4xl">133          {r.countries.map((c, i) => (134            <span key={c.id}>135              {i > 0 ? <span className="mx-1.5 text-ink-3">{t('compare.vs')}</span> : null}136              <span aria-hidden>{c.flag} </span>137              {c.name}138            </span>139          ))}140        </h1>141        <p className="sr-only">{t('compare.headingTab', { names, tab: tabLabel })}</p>142        <p className="mt-2 text-xs text-ink-3">{t('compare.colourNote')}</p>143      </header>144145      <CompareControls countries={countries} allCountries={all} state={state} maxYear={maxYear} downloadHref={downloadHref} />146147      {state.tab === 'snapshot' && countries.length === 2 ? (148        <Section id="h2h" title={t('compare.h2h.title')} subtitle={t('compare.h2h.sub')} className="border-t-0">149          <HeadToHead rows={rows} countries={countries} slugs={slugs} state={state} />150        </Section>151      ) : null}152      {state.tab === 'snapshot' ? (153        <Section id="snapshot" title={t('compare.snapshot.title')} subtitle={t('compare.snapshot.sub')} className={countries.length === 2 ? undefined : 'border-t-0'}>154          <SnapshotTable rows={rows} countries={countries} slugs={slugs} state={state} />155        </Section>156      ) : null}157158      {state.tab !== 'snapshot' ? (159        <>160          {state.tab === 'custom' ? (161            <Section id="custom" title={t('compare.custom.title')} subtitle={t('compare.custom.sub', { max: 6 })} className="border-t-0 pb-4 md:pb-6">162              <CustomPanel selected={rows.map((row) => row.indicator)} state={state} />163            </Section>164          ) : null}165166          {state.indicator && heroCard ? (167            <div className={state.tab === 'custom' ? 'pb-6' : 'pb-6 pt-6 md:pt-8'}>168              <CompareChart indicator={heroCard} countries={countries} state={state} initial={hero?.series ?? null} snapshot={heroRow?.values ?? null} hero maxYear={maxYear} />169            </div>170          ) : null}171172          {topic ? (173            <Section id={`charts-${topic}`} title={t('compare.charts.title', { topic: topicDef?.name ?? topic })} subtitle={gridRows.length ? t('compare.charts.sub', { n: rows.length }) : undefined} className={state.indicator ? undefined : 'border-t-0'}>174              {gridRows.length === 0 ? (175                <p className="py-6 text-sm text-ink-3">{t('compare.charts.none', { topic: (topicDef?.short ?? topic).toLowerCase() })}</p>176              ) : (177                <ChartSections rows={gridRows} countries={countries} slugs={slugs} state={state} eager={eager} maxYear={maxYear} anchorPrefix={`sub-${topic}`} />178              )}179            </Section>180          ) : null}181182          {state.tab === 'custom' && gridRows.length ? (183            <div className="pb-10">184              <ChartGrid rows={gridRows} countries={countries} state={state} eager={eager} maxYear={maxYear} />185            </div>186          ) : null}187188          {rows.length ? (189            <Section id="snapshot-tab" title={t('compare.snapshot.title')} subtitle={topicDef ? t('compare.snapshot.subTopic', { topic: topicDef.name }) : t('compare.snapshot.sub')} level={3}>190              <SnapshotTable rows={rows} countries={countries} slugs={slugs} state={state} />191            </Section>192          ) : null}193        </>194      ) : null}195    </>196  );197}198